类与对象(中)

1. 类的 6 个默认成员函数

默认成员函数的含义
即使一个类什么都不写,编译器也会默认生成 4 以下个成员函数:

  1. 默认构造函数(初始化对象时调用)。
  2. 拷贝构造函数(用已有对象创建新对象时调用)。
  3. 析构函数(对象生命周期结束时调用)。
  4. 赋值运算符重载(用 = 赋值时调用)。

可以直接使用的操作符:

  1. 取地址操作符 &
  2. 常量取地址操作符 const &

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;

class Example
{
// 空类,未显式定义任何成员函数
};

int main()
{
Example e1; // 调用默认构造函数
Example e2 = e1; // 调用拷贝构造函数
Example* ePtr = &e1; // 调用取地址操作符

return 0; // 在程序结束时,e1 和 e2 调用析构函数
}

传道解惑:

Q1:e1——类的实例化:

  • 实例化(Instantiating)指的是通过类来创建对象的过程。当你写出类似 Example e1; 的代码时,e1 就是 Example 类的一个实例(对象)。实例化的本质就是通过类(Example)来创建一个具体的对象(e1)。
  • 是一种模板或者蓝图,它描述了对象的属性和行为,但它本身不是一个具体的对象。类可以看作是定义对象的结构和功能,而实例化就是将类的结构和功能变成一个可以使用的具体对象。

举个例子

1
2
3
4
5
6
7
8
9
10
11
>class Example
>{
>public:
int value; // 类的成员
>};

>int main()
>{
Example e1; // 创建一个名为 e1 的 Example 类对象
return 0;
>}
  • Example 是一个类,定义了一个成员变量 value
  • e1 是通过 Example 类进行实例化的对象。它是 Example 类的一个具体实例。

Q2:ePtr —— Example 类的指针:

1
>Example* ePtr = &e1;
  • ePtr 是一个指向 Example 类对象的指针。Example* 表示“指向 Example 类的指针”。
  • &e1 表示取 e1 对象的地址,也就是 ePtr 指向 e1 的内存位置。
  • ePtr 可以通过指针操作访问 e1 对象的成员(虽然在这个示例中 Example 类没有成员)。

==注意==:如果用户显式定义任何一个成员函数,编译器将不再生成对应的默认版本。


2. 构造函数

构造函数(Constructor):是类的一个特殊成员函数,用于在创建对象时进行初始化。构造函数的名字与类名相同,并且没有返回值。它可以是 无参构造函数(即默认构造函数)或者 带参构造函数(即带参数的构造函数),主要是用于初始化对象的成员函数,可以有参数。

  • 无参构造函数(Default Constructor):也称作 默认构造函数,是一种特殊类型的构造函数,没有参数。它用于当创建对象时不需要传递任何参数,如果类没有其他构造函数,编译器通常会自动提供一个默认的版本。(所以,默认构造函数是一种特殊的构造函数,是构造函数的一种形式,可以是显式定义的,也可以是编译器自动生成的,通常在创建对象时,如果没有传递任何参数,就会调用它)
  • 带参构造函数(Parameterized Constructor):是另一种构造函数,它 带有参数,在创建对象时,通过传递不同的参数来定制对象的初始化。

构造函数是 C++ 中用于初始化对象的特殊函数。它的名字虽然叫“构造”,但它的主要任务不是开辟内存空间,而是初始化对象的成员变量。以下是构造函数 7 个特征:

1. 函数名与类名相同

  • 构造函数的名字和类名完全一样,这是 C++ 的规定,用于识别构造函数。

  • 例子:

1
2
3
4
5
6
7
8
>class MyClass
>{
>public:
MyClass()
{
// 初始化代码
}
>};

2. 无返回值

  • 构造函数没有返回值,也不能定义返回类型(包括 void)。
  • 理由:构造函数的目的是初始化对象,不需要返回任何东西,调用它的结果就是一个已初始化的对象。

3. 对象实例化时,自动调用构造函数

  • 构造函数会在对象创建时由编译器自动调用,无需显式调用。例如:
1
2
3
4
5
6
7
8
9
10
>class MyClass
>{
>public:
MyClass()
{
cout << "构造函数被调用!" << endl;
}
>};

>MyClass obj; // 自动调用构造函数

4. 构造函数可以重载

  • 构造函数支持重载(即可以有多个构造函数,但参数列表必须不同)。

  • 通过重载,用户可以根据需要初始化对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>class MyClass
>{
>public:
MyClass()
{
// 无参构造函数
}
MyClass(int x)
{
// 带参数的构造函数
}
>};

>MyClass obj1; // 调用无参构造函数
>MyClass obj2(10); // 调用带参构造函数

5. 默认构造函数的生成规则

  • 如果类中没有显式定义无参构造函数,编译器会自动生成一个默认无参构造函数。

  • 如果用户显式定义了任意构造函数(无论是否带参数),编译器将不再生成默认无参构造函数,除非显式定义一个无参构造函数。

  • 例子:

1
2
3
4
5
6
7
8
9
10
11
12
>class MyClass
>{
int x;
>};
>MyClass obj; // 自动生成无参默认构造函数

>class MyClass2
>{
>public:
MyClass2(int y) {}
>};
>MyClass2 obj2; // 错误,因为没有无参构造函数

6. 默认构造函数对内置和自定义类型的处理

  • 内置类型(如 int, char

  • 默认构造函数不会初始化内置类型成员,成员变量可能是随机值。例如:

1
2
3
4
class MyClass
{
int x; // 默认值是随机的
};
  • 解决方法:使用 C++11 提供的默认值:
1
2
3
4
class MyClass
{
int x = 0; // 默认值为 0
};
  • 自定义类型(如 class, struct

  • 编译器生成的默认构造函数会调用这些类型成员的默认构造函数。

  • 例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;

class Inner
{
public:
Inner()
{
cout << "Inner 构造函数" << endl;
}
};

class Outer
{
Inner obj; // 自定义类型成员
};

int main()
{
Outer obj; // 实例化自定义类型成员

return 0;
}

输出:Inner 构造函数

7. 默认构造函数的种类

无参的构造函数全缺省的构造函数(所有参数都有默认值的构造函数)都称为 默认构造函数,并且默认构造函数只能有一个。

注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。

  • 无参构造函数:没有任何参数的构造函数。

  • 全缺省构造函数:所有参数都有默认值的构造函数。 两者都属于默认构造函数的范畴,但它们的定义和使用场景有所不同。

  • 例子:

1
2
3
4
5
6
>class MyClass
>{
>public:
MyClass() {} // 无参构造函数
MyClass(int x = 0) {} // 全缺省构造函数
>};

构造函数的核心是初始化对象的成员,而非创建内存空间。默认构造函数的行为取决于对象成员的类型:

  • 内置类型成员默认不初始化,可能是随机值。
  • 自定义类型成员会自动调用其默认构造函数。

通过合理设计构造函数,可以确保对象在创建时处于有效的初始状态。(在 C++11 及更高版本中,编译器还会生成 移动构造函数移动赋值运算符,用于优化资源管理。这些函数允许对象在移动语义下高效地转移资源,而不是进行拷贝。例如,移动构造函数可以将一个临时对象的资源直接转移给新对象,避免不必要的拷贝操作。这些内容将在后续章节中详细介绍。)

2.1. 无参构造函数(默认构造函数)

没有参数,创建对象时会被默认调用。

  • 如果用户没有定义任何构造函数,编译器会生成一个无参构造函数。
  • 一旦用户定义了任何构造函数,编译器将不再生成。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Date
{
public:
Date()
{
year = 2025;
month = 1;
day = 1;
cout << "无参构造函数被调用" << endl;
}

void Print()
{
cout << year << "-" << month << "-" << day << endl;
}

private:
int year, month, day;// 私有成员变量
};

int main()
{
Date d; // 调用无参构造函数,注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
d.Print(); // 输出:2025-1-1
return 0;
}

2.2 带参构造函数

可接受参数,并根据用户输入初始化对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Date
{
public:
Date(int y, int m, int d)
: year(y),
month(m),
day(d)
{

}
// 如果只定义了带参构造函数,编译器不会生成默认无参构造函数
void Print() { cout << year << "-" << month << "-" << day << endl; }

private:
int year, month, day;
};

int main()
{
Date d(2025, 1, 1); // 正常调用
// Date d2; // 错误:没有默认无参构造函数
return 0;
}

传道解惑:

Q1:公有(public)私有(private)部分的关系:

在 C++ 中,类的成员(变量和函数)可以被指定为 公有(public)私有(private),这些访问控制符决定了成员的访问权限。接下来,我会详细讲解 公有部分私有部分 的作用和关系。

公有部分(public)

  • 访问权限: 任何外部代码(比如 main() 函数)都可以直接访问和修改公有成员。简单来说,公有部分 的成员对外部可见。
  • 适用场景: 一般情况下,我们会将类中的 接口函数需要外部访问的数据 声明为公有,以便外部能够与对象交互。

私有部分(private)

  • 访问权限: 私有成员 只能在类内部的成员函数中访问,外部的代码无法直接访问或修改这些成员。私有部分 用于隐藏类的内部实现细节,只允许通过公有的接口与外部交互。
  • 适用场景: 私有成员通常是 类的内部数据,这些数据不希望被外部代码随意修改。通过这种方式,我们可以控制数据的访问权限和保证数据的正确性(通过公有函数进行访问或修改)。

关系

  • 数据封装(Encapsulation): 这就是面向对象编程的核心之一,即将数据和操作数据的代码封装在一起。私有成员数据保护了对象的内部状态,避免了外部对数据的不恰当修改。公有函数则为外部提供了访问和修改数据的接口。
  • 分离接口与实现: 通过将接口函数(如 Print())放在公有部分,类的使用者只需关心接口如何使用,而不需要知道具体的实现方式。私有部分负责实现细节,公有部分提供与外界的交互。
  • 保护: 私有成员提供类的内部实现,而公有成员则提供与外部的交互接口。这种设计有助于 封装数据保护,确保类的使用者不破坏对象的状态。

Q2:私有变量是否必须在公有部分出现?

不需要。

类中的私有变量 (private) 是可以仅供类内部使用的,它们不一定需要通过公有部分 (public) 暴露出来。私有成员通常是不公开给外部的,所以是非必须出现在公有部分的。实际上,类的设计应该遵循数据隐藏原则:即只有通过公有函数,外部才能间接地访问或修改私有数据。这样能保护数据的完整性,避免外部代码直接改变私有数据。如果你希望外部能够访问和操作这些私有变量,通常会提供一些公有方法(如 gettersetter)来间接操作它们,但这不是强制的。

Q3:关于编译器生成的默认成员函数,在不实现构造函数的情况下,编译器会生成默认的构造函数。但是看起来默认构造函数又没什么用?对象调用了编译器生成的默认构造函数,但是对象里依旧是随机值。也就是说,在这里编译器生成的默认构造函数并没有什么用?

解答:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char...,自定义类型就是我们使用 class/struct/union 等自己定义的类型,编译器生成默认的构造函数会对自定义类型成员调用的它的默认成员函数。即编译器生成的默认构造函数在类的成员变量是自定义类型(非内置类型)时,会自动调用这些自定义类型的默认构造函数。这是 C++ 的一个机制,用于确保对象的每个成员都得到正确的初始化。

具体解释

  1. 内置类型(如 intfloat 等):
  • 编译器生成的默认构造函数不会对内置类型成员进行初始化,这些成员会是随机值。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    class MyClass
    {
    public:
    int x; // 内置类型成员
    };

    MyClass obj; // 编译器生成的默认构造函数不会初始化 x
    cout << obj.x; // 随机值
  1. 自定义类型(如 classstruct):
  • 如果类中包含自定义类型的成员变量,编译器生成的默认构造函数会自动调用这些成员的默认构造函数,以初始化它们。

  • 例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class Inner
    {
    public:
    Inner()
    {
    cout << "Inner 的默认构造函数被调用" << endl;
    }
    };

    class Outer
    {
    public:
    Inner innerMember; // 自定义类型成员
    };

    Outer obj; // 自动调用编译器生成的默认构造函数

    输出:Inner 的默认构造函数被调用
  • 在这个例子中,Outer 类没有显式定义构造函数,但编译器生成了一个默认构造函数。而这个默认构造函数在初始化 innerMember 时,自动调用了 Inner 类的默认构造函数。

为什么这样设计?

  • 自定义类型的对象可能需要复杂的初始化工作,比如为动态分配内存、初始化状态等。默认构造函数确保这些工作在创建对象时正确完成。
  • 内置类型如 intfloat 等通常不需要调用构造函数,默认值可以通过 C++11 中的默认值赋予。

当类中有自定义类型的成员变量时:

  1. 如果没有显式定义构造函数,编译器会生成一个默认构造函数。
  2. 这个默认构造函数会对 自定义类型成员变量 调用它们自己的默认构造函数,确保它们被正确初始化。

这意味着你无需手动初始化这些成员,自定义类型的构造函数会自动运行完成初始化工作。


3. 析构函数

析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而 对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。即用于释放资源。

析构函数是 C++中的一个特殊成员函数,专门用于对象生命周期结束时释放资源。以下是对上述特征的通俗易懂的解释:

3.1 析构函数的 6 个特征:

1. 析构函数的命名规则:类名前加字符 ~

析构函数的名字和类名类似,但前面加了一个波浪号 ~,它是 C++的规定,用来显式区分析构函数。比如:

1
2
3
4
5
6
7
8
>class MyClass
>{
>public:
~MyClass()
{
// 析构代码
}
>};

2. 无参数、无返回值类型

析构函数不接受任何参数,也不能返回任何值。它的作用是清理资源,而不是用来进行复杂的逻辑处理,因此不需要参数或返回值。例如:

1
>~MyClass(); // 不允许带参数或返回值

3. 一个类只能有一个析构函数,且不能重载

析构函数唯一且不能有多个版本。C++编译器在编译时,知道如何自动调用析构函数,因此多版本没有意义。

4. 对象生命周期结束时,自动调用析构函数

当一个对象不再需要时(如超出作用域、程序结束或显式删除时),C++编译器会自动调用析构函数,无需手动调用。例如:

1
2
3
>{
MyClass obj; // 创建对象,调用构造函数
>} // 作用域结束,析构函数自动调用

如果对象是动态分配的,用 delete 释放时也会调用:

1
2
>MyClass* p = new MyClass(); // 动态创建对象
>delete p; // 自动调用析构函数

5. 默认析构函数:处理类中自定义类型成员的析构

如果没有显式定义析构函数,编译器会生成一个 默认析构函数。它会对类中所有非基本类型的成员调用其析构函数,比如:

1
2
3
4
>class MyClass
>{
std::string str; // 自定义类型成员
>};

编译器生成的默认析构函数会自动调用 std::string 的析构函数,释放其内部的资源。

6. 没有资源时可以不写,有资源时必须写

  • 没有资源:如果类中没有动态分配的资源,直接使用编译器生成的默认析构函数即可,在这种情况下,系统生成的默认析构函数已经足够。比如:
1
2
3
4
5
class Date
{
public:
int year, month, day; // 无动态资源
};
  • 有资源:如果类中有动态分配的资源(如 new 申请的内存),一定要定义析构函数,手动释放这些资源,否则会造成 内存泄漏
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Stack
{
private:
int* arr;
public:
Stack(int size)
{
arr = new int[size]; // 动态分配内存
}
~Stack()
{
delete[] arr; // 释放内存,避免泄漏
}
};

析构函数的核心作用是 清理资源,尤其是在有动态分配资源的情况下。如果没有动态资源,则可以省略不写。C++的编译器会在合适的时候自动调用析构函数,无需我们手动干预。记住:如果忘记写析构函数释放资源,程序可能会导致 内存泄漏,尤其是当类中包含动态内存时!

3.2 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
>#include <iostream>
>using namespace std;

>class Example
>{
>public:
Example()
{
cout << "构造函数被调用" << endl;
}

~Example()
{
cout << "析构函数被调用" << endl;
}
>};

>int main()
>{
Example e; // 创建对象时调用构造函数
// 程序结束时,e 被销毁,析构函数自动调用
return 0;
>}

输出

1
2
>构造函数被调用
>析构函数被调用

4. 拷贝构造函数

拷贝构造函数是 C++ 中的一个特殊构造函数,用于 通过已有对象初始化一个新对象(创建一个与已有对象内容完全相同的新对象)。

4.1 拷贝构造函数的 5 个特征:

1. 拷贝构造函数是构造函数的一个重载形式

  • 拷贝构造函数和普通构造函数一样是初始化对象的,但它专门用于用 另一个对象 初始化当前对象。

  • 它是构造函数的一种重载形式,形式如下:

1
2
3
4
5
>class MyClass
>{
>public:
MyClass(const MyClass& obj); // 拷贝构造函数声明
>};
  • 注意:拷贝构造函数的名字和类名一样,只是参数是当前类对象的引用。

2. 参数必须是类类型的引用

  • 拷贝构造函数的参数只能是 类类型的引用,不能用 值传递,否则会导致编译器报错或发生死循环:
1
>MyClass(const MyClass obj); 	// 错误,值传递会导致递归调用
  • 这是因为:
  1. 如果参数使用值传递(MyClass obj),在传递参数时会调用拷贝构造函数。
  2. 拷贝构造函数又会调用自己,导致无限递归,最终栈溢出。
  • 正确形式:
1
>MyClass(const MyClass& obj); // 使用引用,避免递归

3. 默认拷贝构造函数

  • 如果用户没有显式定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数
  • 默认拷贝构造函数会逐字节拷贝对象的成员变量,这种拷贝被称为 浅拷贝

浅拷贝的含义:

  • 对于 内置类型(如 intfloat),直接复制值。
  • 对于 自定义类型 成员,编译器会调用这些成员自己的拷贝构造函数。
  • 例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
>#include <iostream>
>using namespace std;

>class MyClass
>{
>public:
int a;
int* p;

MyClass() : a(0), p(nullptr) {} // 默认构造函数
MyClass(const MyClass& other) : a(other.a), p(other.p) {} // 拷贝构造函数(浅拷贝)

// 析构函数,用于释放动态分配的内存
~MyClass()
{
if (p != nullptr)
{
delete p; // 如果 p 指向动态分配的内存,释放它
}
}
>};

>int main()
>{
MyClass obj1;
obj1.a = 10;
obj1.p = new int(20); // 动态分配内存

MyClass obj2 = obj1; // 浅拷贝:obj2 会与 obj1 共享相同的指针 p

// 输出 obj1 和 obj2 的值
cout << "obj1.a = " << obj1.a << ", obj1.p = " << *obj1.p << endl;
cout << "obj2.a = " << obj2.a << ", obj2.p = " << *obj2.p << endl;

// 手动释放 obj1 的动态内存
delete obj1.p;

// 现在 obj2.p 是悬空指针,访问它会导致未定义行为
// cout << "obj2.p after obj1.delete: " << *obj2.p << endl; // 危险!

return 0;
>}

问题:如果类中有指针等动态资源,浅拷贝会导致问题。比如 obj1.pobj2.p 指向同一块内存,释放时可能导致重复释放(悬空指针)。

4. 是否需要显式定义拷贝构造函数?

  • 没有动态资源时(不涉及指针或资源申请): 编译器生成的默认拷贝构造函数已经可以正常工作,可以不写。

  • 有动态资源时(涉及指针或资源申请): 必须显式定义拷贝构造函数,完成深拷贝,避免内存管理问题。

  • 深拷贝的实现: 深拷贝指的是重新分配内存,并复制内容,而不是直接复制指针地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyClass
{
public:
int a;
int* p;

// 自定义拷贝构造函数
MyClass(const MyClass& obj)
{
a = obj.a;
p = new int(*obj.p); // 深拷贝,重新分配内存
}

~MyClass()
{
delete p; // 释放动态资源
}
};

5. 拷贝构造函数的典型调用场景

  • 场景 1:使用已有对象创建新对象
1
2
>MyClass obj1;       	// 普通构造函数
>MyClass obj2 = obj1; // 拷贝构造函数
  • 场景 2:函数参数为类类型对象
1
>void func(MyClass obj); // 如果不使用引用,会调用拷贝构造函数
  • 场景 3:函数返回类类型对象
1
2
3
4
5
>MyClass func()
>{
MyClass obj;
return obj; // 返回时可能调用拷贝构造函数
>}

提高效率的建议

  1. 传参时使用引用:避免拷贝对象的开销。
1
void func(const MyClass& obj); // 使用引用,不调用拷贝构造函数
  1. 返回值优化(C++11 的移动语义): 对于返回值尽量结合移动构造函数使用,减少不必要的拷贝。

综上:

  • 拷贝构造函数是用已有对象初始化新对象的工具。
  • 编译器默认的拷贝构造函数使用浅拷贝,只能处理简单类(没有动态资源)。
  • 对于涉及动态资源的类,必须显式定义拷贝构造函数,确保深拷贝,避免内存泄漏或重复释放。

4.2 默认拷贝构造函数

编译器默认会生成一个浅拷贝版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Date
{
public:
Date(int y, int m, int d) : year(y), month(m), day(d) {}
void Print() { cout << year << "-" << month << "-" << day << endl; }

private:
int year, month, day;
};

int main()
{
Date d1(2025, 1, 1);
Date d2 = d1; // 调用默认拷贝构造函数
d2.Print(); // 输出:2025-1-1
return 0;
}

4.3 自定义拷贝构造函数

当对象包含动态资源时,必须显式定义深拷贝逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Example
{
public:
Example(int size)
{
arr = new int[size];
this->size = size;
}

// 自定义拷贝构造函数
Example(const Example& other)
{
size = other.size;
arr = new int[size];
for (int i = 0; i < size; i++)
{
arr[i] = other.arr[i];
}
}

~Example() { delete[] arr; }

private:
int* arr;
int size;
};

5. 赋值运算符重载

C++ 中的 运算符重载 允许我们为类或对象定义新的运算符行为,使代码更直观和可读。

5.1 对运算符重载关键点解释:

1. 运算符重载的本质

  • 运算符重载是通过定义一个特殊的函数,改变运算符对类对象的行为。

  • 函数名称: operator 后接具体运算符。例如:operator+ 是用来重载加法运算符的函数。

  • 函数原型:

1
>返回值类型 operator操作符(参数列表);
  • 例子:
1
2
3
4
5
>class MyClass
>{
>public:
MyClass operator+(const MyClass& obj);
>};

2. 运算符重载规则

  • 不能创建新的运算符: 只能重载已有运算符,不能重载不存在的符号(如 operator@ 是非法的)。
  • 内置类型的运算符不能改变其含义: 例如整数相加(3 + 5)的行为不能被改变。
  • 某些运算符不能被重载: ., .*, ::, sizeof, ?: 不能被重载。
  • 至少有一个操作数是类类型: 不能对完全是内置类型的运算符重载,比如试图重载 int + int

3. 成员函数 vs 全局函数重载

  • 运算符重载可以是类的 成员函数全局函数
  • 成员函数重载: 第一个操作数是当前对象,编译器会将操作数传递给隐藏的 this 指针。
  • 全局函数重载: 需要将两个操作数都作为参数传递。
  • 注意:赋值运算符重载(operator=)必须是成员函数,不能是全局函数。

4. 常见运算符重载的实现


赋值运算符重载(operator=

特点:

  1. 参数类型: const T&,避免不必要的拷贝,传引用效率更高。
  2. 返回值类型: T&,返回对象的引用支持连续赋值(如 a = b = c)。
  3. 检测自赋值: 检查是否给自己赋值(if (this == &obj))。
  • 自赋值检查非常重要,因为如果不检查自赋值,可能会导致资源被错误释放或重复释放。例如,如果 this == &obj,则 delete data; 会释放当前对象的资源,导致后续操作无法正确执行。
  1. 返回 *this 让赋值语句返回当前对象。

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>class MyClass
>{
>public:
int* data;

MyClass& operator=(const MyClass& obj)
{
if (this != &obj) // 检测是否自赋值
{
delete data; // 释放已有资源
data = new int(*obj.data); // 深拷贝,避免共享指针导致问题
}
return *this; // 返回当前对象的引用
}
>};

前置++ 和 后置++ 重载

  • 前置++(++obj): 返回增加后的值。
  • 后置++(obj++): 返回增加前的旧值,调用时会多传一个 int 参数(编译器自动处理)。

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>class MyClass
>{
>public:
int value;

// 前置++,返回引用
MyClass& operator++()
{
++value; // 自增
return *this; // 返回当前对象
}

// 后置++,返回值
MyClass operator++(int)
{
MyClass temp = *this; // 保存当前对象
++value; // 自增
return temp; // 返回旧值
}
>};

加法运算符重载(operator+

加法运算符重载支持对象间的相加操作。

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
>class MyClass
>{
>public:
int value;

// 重载加法运算符
MyClass operator+(const MyClass& obj) const
{
MyClass result;
result.value = this->value + obj.value; // 两个对象相加
return result;
}
>};

5. 默认运算符重载 vs 自定义实现

默认行为:

  • 如果不显式实现赋值运算符(operator=)或拷贝构造函数(operator=),编译器会生成默认版本,逐字节拷贝。
  • 默认行为对于 内置类型(如 intchar)完全适用。
  • 对于 动态资源(如指针),默认行为可能导致 浅拷贝 问题(资源共享导致重复释放或悬空指针)。

是否需要自定义实现:

  • 如果类中没有动态资源(如只包含内置类型或 STL 容器),默认生成的运算符重载就够用。
  • 如果类中有动态资源(如指针),必须自定义运算符重载,确保深拷贝和正确释放资源。

6. 为什么返回引用?

  • 在运算符重载中,返回引用(T&)有助于提高效率,并支持连续赋值操作(如 a = b = c)。

  • 例如赋值运算符:

1
2
3
4
5
>MyClass& operator=(const MyClass& obj)
>{
// 处理赋值逻辑
return *this; // 返回当前对象的引用
>}
  • 如果返回值而非引用,则每次赋值都会产生一个临时对象,效率低。

总结

运算符重载让类对象可以像内置类型一样进行操作,提升了代码的可读性。但以下几点需要注意:

  1. 只有已有的运算符可以被重载,且至少有一个操作数是类类型。
  2. 对于有动态资源的类,必须重载运算符以避免浅拷贝问题。
  3. 返回引用是为了支持连续赋值和提高效率。
  4. 某些运算符不能被重载,比如 .::sizeof?:

通过合理设计运算符重载,可以让类使用起来更像内置类型,从而写出更加优雅和简洁的代码。

5.2 示例:

1. 默认实现

编译器默认按字节拷贝,可能造成资源冲突。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>class Date
>{
>public:
Date(int y, int m, int d) : year(y), month(m), day(d) {}
void Print() { cout << year << "-" << month << "-" << day << endl; }

>private:
int year, month, day;
>};

>int main()
>{
Date d1(2025, 1, 1);
Date d2(2024, 12, 31);
d2 = d1; // 调用默认赋值运算符
d2.Print(); // 输出:2025-1-1
return 0;
>}

2. 重载赋值运算符

当对象管理动态资源时,需要自定义赋值运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
>class Example
>{
>public:
Example(int size)
{
arr = new int[size];
this->size = size;
}

Example& operator=(const Example& other)
{
if (this != &other) // 防止自赋值
{
delete[] arr;
size = other.size;
arr = new int[size];

for (int i = 0; i < size; i++)
{
arr[i] = other.arr[i];
}
}

return *this;
}

~Example() { delete[] arr; }

>private:
int* arr;
int size;
>};

重载 +

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>class Date
>{
>public:
Date(int y, int m, int d) : year(y), month(m), day(d) {}
Date operator+(int days) { return Date(year, month, day + days); }
void Print() { cout << year << "-" << month << "-" << day << endl; }

>private:
int year, month, day;
>};

>int main()
>{
Date d(2025, 1, 1);
Date d2 = d + 5; // 日期加5天
d2.Print(); // 输出:2025-1-6
return 0;
>}

重载 ==

1
2
3
4
5
6
7
8
9
10
11
12
>class Date
>{
>public:
Date(int y, int m, int d) : year(y), month(m), day(d) {}
bool operator==(const Date& other)
{
return year == other.year && month == other.month && day == other.day;
}

>private:
int year, month, day;
>};

6. const 成员

const 修饰的“成员函数”称之为 const 成员函数,const 成员函数修饰了隐含的 this 指针为 const 类型(const ClassName* this),表示该函数不能修改类的成员变量。但可以通过 mutable 修饰的成员变量在 const 成员函数中进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}

void Print()
{
cout << "Print()" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl;
}

void PrintConst() const
{
cout << "Print(const)" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl;
}

private:
int _year; // 年
int _month; // 月
int _day; // 日
};

void Test()
{
Date d1(2025, 1, 1);
d1.Print();

const Date d2(2025, 1, 1);
d2.Print();
}

让我们逐步理解上面代码:

  1. 定义了两个 Print 函数:
    • void Print():普通成员函数,允许修改对象。
    • void Print() constconst 成员函数,不能修改对象。
  2. Test 函数中:
    • Date d1(2022, 1, 13); 是普通对象,调用 d1.Print() 时,编译器会选择非 const 版本的 Print()
    • const Date d2(2022, 1, 13);const 对象,调用 d2.Print() 时,编译器会选择 const 版本的 Print()

6.1 总结规则:

  1. const 对象
    • 可以调用普通成员函数。
    • 可以调用 const 成员函数。
  2. const 对象
    • 只能调用 const 成员函数。
  3. 普通成员函数
    • 可以调用普通成员函数。
    • 不能调用 const 成员函数。
  4. const 成员函数
    • 不能调用普通成员函数。
    • 可以调用 const 成员函数。

6.2 适用场景:

  • 当你希望某个成员函数不修改对象的任何成员变量时,应将其声明为 const 成员函数。
  • 这样可以确保该函数在 const 对象上调用时不会破坏对象的不可变性。
  • 例如,Print() 函数通常不需要修改对象状态,因此可以声明为 const 成员函数。

6.3 关键知识点:

  • const 修饰的成员函数:会将隐含的 this 指针转换为 const 类型(const ClassName* this)。
  • 编译器通过 const 限定符,保证不会在 const 成员函数中修改成员变量。
  • 如果非 const 成员变量必须在 const 成员函数中被修改,可以使用 mutable 关键字修饰这些变量,使其在 const 上下文中也可变(但要谨慎使用)。

传道解惑:

Q1: const 对象可以调用非 const 成员函数吗?

不可以

const 对象意味着这个对象的任何成员都不能被修改。非 const 成员函数没有限制修改成员变量的行为,因此 const 对象无法调用非 const 成员函数,否则会破坏 const 对象的不变性。

在上面代码中,const Date d2 是一个 const 对象,只能调用 Print(const),不能调用 Print()(非 const 成员函数)。编译器会通过检查 const 属性阻止这种调用。


Q2:const 对象可以调用 const 成员函数吗?

可以

const 成员函数会修饰 this 指针为 const Date* this,表示该函数内部不会修改类的成员变量。因此 const 对象可以调用 const 成员函数,因为这不会破坏 const 对象的不可变性。

在上面代码中,d2.Print() 调用的就是 Print(const),因为 Print(const)const 成员函数。


Q3:const 成员函数内可以调用其它的非 const 成员函数吗?

不可以

因为 const 成员函数的 this 指针是 const 的,即 const Date* this,表示它不能修改成员变量。而非 const 成员函数默认的 this 指针是 Date* this,允许修改成员变量。
因此,const 成员函数无法调用非 const 成员函数,因为这样可能导致间接修改成员变量,从而违反了 const 的约束。


Q4: const 成员函数内可以调用其它的 const 成员函数吗?

可以

const 成员函数的 this 指针是 const 的,调用其他 const 成员函数不会违反 const 的约束,因为 const 成员函数保证不修改成员变量。

7. 取地址及 const 取地址操作符重载

在 C++中,取地址操作符 & 不能被真正重载。但可以通过定义特殊的成员函数来改变其行为。这种实现方式类似于运算符重载,但并非真正的重载。结合 const,它的行为会更有针对性。(取地址操作符 & 不能被重载的原因是,它是一个基础操作符,用于获取对象的地址。如果允许重载,可能会导致地址获取的语义混乱。)

7.1 取地址操作符 & 默认行为

默认情况下,& 操作符返回对象的内存地址。例如:

1
2
int x = 10;
int* px = &x; // px 获取了 x 的地址

在类中,取地址操作符通常也可以被用来获取对象的地址。

7.2 取地址操作符的重载

C++中无法重载取地址操作符 &,但可以通过定义成员函数来改变其行为。例如通过返回自定义指针对象,而不是直接返回 this。重载时可以区分:

  1. 普通对象的取地址。
  2. const 对象的取地址。

7.3 代码示例与解释

重载取地址操作符的代码,以下是一个简单示例,展示如何通过 const 和非 const 版本的重载来实现不同的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
using namespace std;

class MyClass
{
public:
MyClass(int value) : _value(value) {}

// 非 const 版本的取地址操作符重载
MyClass* operator&()
{
cout << "非 const 取地址操作符被调用" << endl;
return this;
}

// const 版本的取地址操作符重载
const MyClass* operator&() const
{
cout << "const 取地址操作符被调用" << endl;
return this;
}

void Display() const
{
cout << "Value: " << _value << endl;
}

private:
int _value;
};

void Test()
{
MyClass obj(10); // 普通对象
const MyClass constObj(20); // const 对象

MyClass* addr1 = &obj; // 调用非 const 版本的取地址操作符
const MyClass* addr2 = &constObj; // 调用 const 版本的取地址操作符

addr1->Display();
addr2->Display();
}

int main()
{
Test();
return 0;
}

输出结果

1
2
3
4
非 const 取地址操作符被调用
const 取地址操作符被调用
Value: 10
Value: 20

代码解释

  1. 普通对象调用非 const 版本的取地址操作符
    • 当我们对普通对象 obj 使用 & 时,调用的是 MyClass* operator&()
    • 返回类型是一个非 const 指针,即 MyClass*
    • 这意味着我们可以通过这个指针修改 obj
  2. const 对象调用 const 版本的取地址操作符
    • 当我们对 const 对象 constObj 使用 & 时,调用的是 const MyClass* operator&() const
    • 返回类型是 const MyClass*,即一个不可修改的指针。
    • 这保证了通过返回的地址无法修改 constObj

传道解惑

Q1:两种版本为什么重要?

  • 如果只有普通的非 const 重载(MyClass* operator&()),const 对象调用时会报错,因为无法确保 const 对象的不可变性。
  • 如果只有 const 版本重载(const MyClass* operator&() const),普通对象也只能得到一个 const 指针,限制了可操作性。

因此,同时提供两个版本:

  • 普通对象取地址时返回普通指针,灵活操作。
  • const 对象取地址时返回 const 指针,保护不可变性。

Q2:取地址操作符重载的应用场景?

  1. 调试日志:可以在取地址时打印出信息,便于调试。
  2. 对象管理:可以控制对象暴露出去的指针,避免外部直接操作原始地址。
  3. 定制行为:对于特定的类,可以在取地址时返回自定义指针对象,而不是直接返回 this

Q3:为什么需要区分 const 对象的取地址?

假如我们不区分 const 和非 const 对象取地址操作,就会产生如下问题:

1
2
>const MyClass constObj(20);
>MyClass* ptr = &constObj; // 如果没有 const 重载,这会破坏 constObj 的不可变性

通过引入 const 版本的取地址操作符重载,编译器可以在 const 对象中强制返回 const 指针,保护数据安全。

  1. 取地址操作符重载的作用:
  • 自定义取地址操作符的行为。
  • 区分普通对象和 const 对象的地址获取方式。
  1. 注意事项:
  • 对普通对象,返回普通指针。
  • const 对象,返回 const 指针,确保不可变性。

这是一种增强代码灵活性与安全性的手段,同时对复杂场景(如调试或资源管理)非常有用。